Skip to content

os: start processes with posix_spawn - #5634

Open
yohimik wants to merge 2 commits into
tinygo-org:devfrom
yohimik:upstream-pr/os-exec-posix-spawn
Open

os: start processes with posix_spawn#5634
yohimik wants to merge 2 commits into
tinygo-org:devfrom
yohimik:upstream-pr/os-exec-posix-spawn

Conversation

@yohimik

@yohimik yohimik commented Aug 30, 2026

Copy link
Copy Markdown

os: start processes with posix_spawn

Depends on upstream-pr/darwin-fcntl on darwin. See "Dependencies".

What this does

The process layer was a stub. StartProcess refused every ProcAttr that
carried Dir, Sys or Files, and os/exec always passes three Files, so
no command could run. Wait, Kill and Signal returned ErrNotImplemented,
and ProcessState was an empty struct whose methods all reported a failure. The
code that did exist was a fork() and an execve() with no branch on the
result of the fork, so the parent fell into the exec as well.

This uses posix_spawn(3) on hosted Linux and macOS, which are the two targets
where the standard library os/exec and syscall packages compile against this
override.

Why posix_spawn and not fork plus exec. Those targets run the threads
scheduler and collect with Boehm, so a fork from Go gives the child one thread
that holds the locks of the other threads, malloc among them, and the
stop-the-world signal of the collector can arrive between the fork and the exec.
posix_spawn does the clone and the exec inside libc, where no Go code runs,
and it reports a failed exec as its return value, so the usual status pipe is
not necessary.

What the change covers.

  • File actions carry the child descriptors. A dup2 for each entry of
    ProcAttr.Files, a close for a missing one and for a standard descriptor
    that Files does not name, and an addchdir_np for Dir.
  • A nil Env means the environment of the parent, as Go documents.
  • The attribute block installs an empty signal mask, because a blocked mask
    survives an exec and the spawning thread can carry the signal of the collector
    blocked.
  • SysProcAttr.Setpgid and Pgid are honoured through
    posix_spawnattr_setpgroup. Every other field is refused by name, and the
    error unwraps to ErrNotImplementedSys.
  • Wait reaps with wait4 and retries on EINTR. ProcessState carries the pid
    and the real syscall.WaitStatus, so exec.ExitError reports "exit status
    N", and ExitCode, Exited, Success and Sys work.
  • Signal refuses a pid that Wait reaped and maps ESRCH to ErrProcessDone,
    which is what exec.CommandContext expects when its context fires as the
    command finishes.
  • os.Pipe on darwin marks both descriptors close-on-exec, under ForkLock,
    because macOS has no pipe2. Linux asks for O_CLOEXEC in pipe2.
  • The builder declares the posix_spawn family in the darwin libSystem stub.
    The minimal macOS SDK in lib/macos-minimal-sdk reads a fixed list of headers
    that does not have <spawn.h>, so a darwin program that starts a process did
    not link.

Targets without a process model keep the previous stubs. Only the build tag on
exec_other.go changes, to let macOS through.

Evidence

src/os/exec_linux_test.go, which asserted the not-implemented errors, is
replaced by src/os/exec_spawn_test.go, which asserts the behaviour and also
covers darwin. The os package is already in TEST_PACKAGES_FAST, so these run
in the linux and the macOS CI jobs with no makefile change.

Thirteen tests cover exit status, a non-zero exit, kill and the signalled
status, ErrProcessDone after a reap, ErrNotExist, Dir, an empty
SysProcAttr, every refused SysProcAttr field, Setpgid with a new group,
Setpgid joining a group, the inherited group, the close of a standard
descriptor that Files does not name, descriptors that must not leak past the
exec, and Files handed to the child.

All pass on macOS 26.6 arm64 with tinygo test os.

A downstream product ships binaries built with these changes in a production
release. dispat v1.4.0 is published and is not a prerelease. It carries
dispat-tiny-linux-amd64 and dispat-tiny-linux-arm64, built by the fork
release v0.42.0-net.4 from sha256-pinned tarballs and smoke-executed under
binfmt before upload, beside six binaries from the gc toolchain.
https://github.com/yohimik/dispat/releases/tag/services%2Fdispat%2Fv1.4.0

The acceptance record of that repository is committed at
packages/docs/docs/internals/tinygo.md. It reports the net.2 to net.4
acceptance history, an integration suite of 694 rows that passes with 0 failures
and 1 documented skip on darwin, and a size table of 0.58x to 0.63x against the
gc equivalents with TinyGo -opt=z -no-debug against go build -trimpath -ldflags "-s -w". Those figures come from that document. They are not a
measurement of this branch.

The suite exercises process spawning with pipes, file I/O, environment
variables, goroutine concurrency under the threads scheduler, and time and
context handling.

Dependencies

  • darwin: needs upstream-pr/darwin-fcntl. os.Pipe on darwin marks the
    descriptors close-on-exec with fcntl, whose variadic argument is not passed
    correctly on darwin/arm64 without that fix, so TestForkExecDescriptorsDoNotLeak
    is not reliable without it. Merge the fcntl PR first, or take the two together.
  • linux: no dependency. pipe2 sets O_CLOEXEC atomically.
  • Concurrency: syscall.ForkLock is an RWMutex, and a program that spawns and
    makes pipes at the same time can stop on the current RWMutex. See
    upstream-pr/sync-rwmutex. It is not needed for the tests here, which are
    sequential, but it is needed for real concurrent use.

Known gaps

  • SysProcAttr fields other than Setpgid and Pgid are refused, each by
    name. Every one of them needs Go code to run in the child between the clone
    and the exec, which is what posix_spawn does not offer.
  • posix_spawn_file_actions_addchdir_np came with macOS 10.15, so a binary from
    this toolchain needs at least that release. The deployment target is lower.
  • Process gains a 4-byte done field that targets without a process model do
    not use.
  • The file actions are added in the order of ProcAttr.Files. A source
    descriptor that is numerically below its own destination index is thus
    overwritten before a later entry can read it.
    syscall.forkAndExecInChild in the standard library moves such a source out
    of the way first. os/exec never builds a layout of that shape, because the
    descriptors that it passes come from pipes and are above 2, so this only
    affects a direct StartProcess call with an unusual Files slice. It can be
    a follow-up, or it can be added here if the maintainers prefer.

Related

Related pull requests

This change is part of one body of work. Together the changes make programs that use the network and child processes work on hosted linux and macOS. A full CLI was tested end to end with all of them and ships binaries built this way, see dispat v1.4.0 in the evidence section.

In this repository

In tinygo-org/net

A merge order that works. The three bug fixes are independent. #5633 goes before #5635. HTTPS on linux needs only #5633 and #5635. Full darwin support also needs #5636, the net changes and a new src/net submodule pin.

The third parameter of fcntl is variadic, and on darwin/arm64 a variadic
argument goes on the stack and not in a register. A call to libc fcntl through
a plain three-argument function pointer thus makes the callee read that
argument from an unrelated stack slot. open() already has a C wrapper for the
same reason.

The symptom is quiet. fcntl(fd, F_SETFD, FD_CLOEXEC) sets the flag or does not,
which depends on the stack contents, so the result is the same for one binary
and different between binaries. syscall.CloseOnExec is the main caller, so when
it fails, every descriptor of the program goes into every process that it
starts. A child that holds a copy of the write end of a pipe keeps that pipe
from a report of EOF, which is how os/exec collects the output of a command.

Measured on macOS 26.6 arm64 before this change, fcntl(fd, F_DUPFD, 100)
returns EINVAL, and three F_SETFL calls with 0x4, 0x0 and 0x8 all leave
F_GETFL with 0x48.

The wrapper takes the argument as a uintptr_t so that the pointer commands
reached through syscall.fcntlPtr use it too. Both spellings go through
libc_fcntl_trampoline, and on a little-endian target the int commands read the
low half of the same stack slot.

The new tests in src/os cover both shapes. TestFcntlSetNonblock fails on darwin
before this change and passes after it.
The process layer was a stub. StartProcess refused every ProcAttr that carried
Dir, Sys or Files, and os/exec always passes three Files, so no command could
run. Wait, Kill and Signal returned ErrNotImplemented, and ProcessState was an
empty struct whose methods all reported a failure. The code that did exist was
a fork() and an execve() with no branch on the result of the fork, so the
parent fell into the exec as well.

Use posix_spawn(3) on hosted Linux and macOS, which are the two targets where
the standard library os/exec and syscall packages compile against this
override. Those targets run the threads scheduler and collect with Boehm, so a
fork from Go gives the child one thread that holds the locks of the other
threads, malloc among them, and the stop-the-world signal of the collector can
arrive between the fork and the exec. posix_spawn does the clone and the exec
inside libc, where no Go code runs, and it reports a failed exec as its return
value, so the usual status pipe is not necessary.

The descriptors of the child come from a file-actions list. There is a dup2 for
each entry of ProcAttr.Files, a close for a missing one, and an addchdir_np for
Dir. A nil Env means the environment of the parent, as Go documents. The
attribute block installs an empty signal mask, because a blocked mask survives
an exec and the spawning thread can carry the signal of the collector blocked.

Setpgid and Pgid are honoured through posix_spawnattr_setpgroup, which is the
one SysProcAttr request that posix_spawn can express. Every other field is
refused by name, and the error unwraps to ErrNotImplementedSys.

Wait reaps with wait4 and retries on EINTR, which a thread in wait4 gets as a
matter of course, because the collector interrupts it. ProcessState now carries
the pid and the real syscall.WaitStatus, so exec.ExitError reports "exit status
N", and ExitCode, Exited, Success and Sys work. A killed child is reported as
signalled. Signal refuses a pid that Wait reaped and maps ESRCH to
ErrProcessDone, which is what exec.CommandContext expects when its context
fires as the command finishes.

macOS has no pipe2, so os.Pipe there marks both descriptors close-on-exec
afterwards, under ForkLock. Without the flag every pipe goes into every child,
and a child that holds a copy of a write end keeps that pipe from a report of
EOF. Linux asks for O_CLOEXEC in pipe2 and gets it atomically.

The minimal macOS SDK in lib/macos-minimal-sdk does not declare <spawn.h>, so
the generated libSystem stub has none of the posix_spawn symbols and a darwin
program that starts a process does not link. The builder now assembles the
missing names into a second stub object. posix_spawn_file_actions_addchdir_np
came with macOS 10.15, so a binary from this toolchain needs at least that
release.

Targets without a process model keep the previous stubs. Only the build tag on
exec_other.go changes, to let macOS through to the new implementation.
@yohimik
yohimik force-pushed the upstream-pr/os-exec-posix-spawn branch from 628c5e6 to 42486b3 Compare September 2, 2026 08:50
@yohimik

yohimik commented Sep 2, 2026

Copy link
Copy Markdown
Author

Rebased on dev after the 0.42.0 release. The change applies on top of v0.42.0
as released without a conflict, and the diff is unchanged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant